Add legacy TypeScript decorator metadata support#754
Conversation
- Fix CI: add missing `decorators` field at three Param init sites in perry-codegen-arkts (lib.rs and phase2_full_app_smoke.rs) that the cross-crate HIR struct update missed. - Move JS-runtime proxy modules out of the user's CWD into a per-process directory under std::env::temp_dir(), so loading JS modules no longer leaves stray `__perry_js_proxy_*.mjs` files in the project root. - Reject getter/setter decorators loudly in validate_legacy_decorators_v1 with a clear error pointing at the accessor; previously they slipped through ClassMember::Method and were silently dropped while private decoration points already failed loud. - Document the int32 / class-ref aliasing contract at the V8 bridge fast path so future callers know raw small positive integers cannot round-trip through JS interop. - Document the GC contract on REFLECT_METADATA: keys are raw NaN-box bits, safe for ClassRef / closure-method targets but not for general heap-pointer targets under the evacuating GC. - Restore the explanatory comment for the `class.id == 0` filter in emit_string_pool — the previous `body.is_empty()` guard's intent was documented and the equivalence isn't self-evident. - Document the bounded leak in class_prototype_method_value_for_name (one allocation per unique (class_id, method_name) the program ever asks for; cached forever). - Harden metadata_key_part: return Option<String>, drop the `bits:<hex>` fallback, and refuse to operate on non-string-coercible keys (Symbols, etc.). define becomes a no-op, get returns undefined, has/delete return false — no more silent ghost entries.
- Rename `validate_legacy_decorators_v1` to `validate_legacy_decorator_surface`; the `_v1` suffix on an internal helper implied a versioned API. - Cache the six CommonJS-detection / wrap regexes (`is_commonjs`, `wrap_commonjs`, `strip_js_comments`) behind `once_cell::sync::Lazy`. These are hot — they run once per loaded JS module. - Factor the eight near-identical `Reflect.*Metadata` dispatch arms in `lower_call.rs` into three `take_reflect_*_args` helpers (kvtp / ktp / tp). - Switch the two `eprintln!` calls in the JS function-value catch handler to `log::error!` so they go through the same logging path as the rest of the surrounding code. No behavior changes; parity decorator suite still 12/12.
The cross-process stable-hash example escaped the workspace-wide grep in the previous commit (it's under examples/, not src/ or tests/). Mirrors the same fix already applied to the other Param init sites.
CI lint (cargo fmt --check) wanted the LINE_COMMENT_RE chain on separate lines.
|
Thanks for putting this together — the implementation is cleaner than I'd expect for a feature this size (additive IR variants, gated runtime cost, no panics in the Reflect surface, real DI simulation in the integration canary). Before merging I want to nail down two questions about what this actually unlocks for users. 1. Does a real NestJS app boot on this? The canaries pass, but they're hand-rolled —
If you can get that working, the DX value is concrete. If you hit walls, the walls are what we need to see — they'll tell us how much further work is needed before this is usable end-to-end. Either way the smoke test belongs in 2. Two silent-failure cases I'd like to convert to compile errors before merge: (a) (b) Class decorators that return a replacement class currently run the decorator for side-effects and discard the return value — Once those two are in and the NestJS smoke test compiles + serves a route, I'm comfortable merging. Appreciate the work — the metadata pipeline itself is well-scoped. |
Maintainer (proggeramlug) called out two silent-failure cases that need to surface as compile errors / runtime throws before merge: (2a) `import "reflect-metadata"` was lowered to `Ok(())`, hiding the fact that Perry's built-in shim is a subset of the npm package's surface. Users importing the npm package may expect class-validator / TypeORM-level coverage. Now emit a one-shot `[perry] note:` to stderr at compile time listing the implemented surface and pointing at the decorator docs. Guarded by an AtomicBool so repeated imports don't spam the user. (2b) Class decorators returning a replacement class used to run for side effects and silently discard the return. Real-world decorators like `@Memoize`, `@Throttle`, and GraphQL resolver wrappers all return wrapped classes — they would compile and execute incorrectly. The fixed lowered class is immutable in the IR, so we can't honor the replacement. New behavior: `append_class_decorator_invocations` captures the return into a temp local and, when `typeof ret === "function"`, throws `TypeError: Class decorator @<name> on <Class> returned a replacement class…`. The `typeof === "function"` check (rather than `!== undefined`) works around an unrelated Perry quirk where function-expression implicit returns leave a numeric sentinel in the return slot — using `!== undefined` would false-positive on bare side-effect-only decorators like `@Injectable`. A class is `typeof "function"` in JS, so checking that bucket precisely catches the class-replacement case the maintainer flagged. Updates `test_decorators_replacement_unsupported` from "replacement ignored" (silent) to "TypeError at decorator application time" (loud), and the limitations doc to match. Parity decorator suite still 12/12.
# Conflicts: # crates/perry-hir/src/ir.rs
Maintainer (proggeramlug) asked for a real end-to-end NestJS test in tests/release/packages/ — not the hand-rolled DI canaries already in test-files/. Wired the fixture and broke the first three walls that stood in the way; documents the remaining wall as a follow-up. ## Fixture (tests/release/packages/nestjs-hello/) - package.json with `@nestjs/common`, `@nestjs/core`, `@nestjs/platform-express`, `reflect-metadata`, `rxjs` + Perry's `compilePackages` set. - entry.ts: minimal @module / @controller / @Injectable / @get + DI + NestFactory.create(AppModule) + app.listen(port). - fixture.sh: npm install → perry compile → run in background → curl / → diff against expected.txt → cleanup. SKIPs with WALLS.md reference if a compile- or startup-wall is hit (the release sweep records the gap without going red). - expected.txt: "curl status: 200\ncurl body: Hello Perry\n". ## Walls resolved by this commit 1. **`util.types` not in API manifest.** Added `property("util", "types")` and shipped a real `types` surface in the `node:util` stub (isPromise / isAsyncFunction / isMap / isUint8Array / isProxy / etc.). NestJS's container probes these during dispatch; defaulting unknown shapes to `false` is the conservative answer. 2. **`super.<prop>` (non-call) lowering hit a hard bail.** rxjs's OperatorSubscriber.ts stores `this._next = onNext ? wrapper : super._next` and similar — value-form super access. Lowered to `this.<prop>` (and `this[expr]` for computed). Correct when the subclass doesn't override the property, which covers the rxjs / NestJS pattern. Strict parent-vtable lookup is a follow-up. 3. **`async_hooks.AsyncResource` not available.** NestJS uses `runInAsyncScope` for request-scoped DI. Added `AsyncResource` + `AsyncLocalStorage` (+ `executionAsyncId`, `createHook`) to a real `node:async_hooks` stub. No real async-context tracking yet — the bind/run shape is enough for the NestJS path. ## What still blocks PASS Wall 4: cross-module method symbol mangling for re-exported classes. After (1)–(3) the compile + codegen succeed and link fails with `_perry_method_node_modules_rxjs_src_index_ts__Subscription__unsubscribe` unresolved — the class is defined in `rxjs/src/internal/Subscription.ts` (prefix `..._internal_Subscription_ts`) but callers reference it via the barrel `rxjs/src/index.ts`. The hono / fastify fixtures don't hit this because they import flat classes from a single file. WALLS.md in the fixture dir tracks this for the next iteration. Once Wall 4 is fixed the fixture should flip from SKIP to PASS (or expose the next layer of walls — same iterative process). Removing WALLS.md turns any compile/startup failure into a hard FAIL, locking in the baseline. Decorator parity still 12/12. perry-codegen tests + manifest drift tests still green (the new manifest entries don't trip the consistency guard at crates/perry-codegen/tests/manifest_consistency.rs).
Drift from the manifest additions in fc020eb (util.types, async_hooks.AsyncResource, async_hooks.AsyncLocalStorage). The CI api-docs-drift gate caught it; regen via scripts/regen_api_docs.sh.
|
Thanks for the structured review. Pushed the work as four commits + a follow-up docs regen on top of the original PR. 2a (reflect-metadata shim diagnostic) — done. 2b (class decorator replacement returns) — done as runtime throw, your suggested fallback. Same commit. 1 (NestJS smoke test) — fixture wired, three walls broken, fourth documented.
Walls fixed in this PR:
Wall 4 — open: cross-module method symbol mangling for re-exported classes. Compile + codegen succeed; link fails on Structurally I think Wall 4 wants its own focused PR — it's surgery on Decorator parity still 12/12. CI is re-running on |
Summary
Adds the first usable legacy TypeScript decorator metadata path for Perry, focused on NestJS-style dependency injection canaries. The compiler keeps using SWC for parsing, but lowers decorators directly through HIR, codegen, and Perry runtime metadata rather than relying on emitted JavaScript helpers.
What changed
design:paramtypes, propertydesign:type, methoddesign:paramtypes, anddesign:returntype.Reflect.*Metadataruntime subset and JS runtime bridge.reflect-metadataimports as compatible with Perry-provided metadata hooks.Compatibility notes
This targets legacy TypeScript decorators because NestJS, TypeORM, and similar frameworks rely on that ecosystem behavior. Full unmodified NestJS or TypeORM compatibility is still broader than this PR, but the main DI metadata path is now represented by focused parity canaries.
Class decorator replacement return values remain unsupported and are covered by an explicit negative canary.
Validation
cargo build --release -p perry-jsruntime -p perry./run_parity_tests.sh --filter decoratorscargo fmt --checkcargo test -p perry-hir -p perry-codegen -p perry-runtime --lib